class: ou alias:, un choix de conteneur Symfony, et un choix dâingĂ©nierie
Pourquoi un fake S3 jetable peut ĂȘtre prĂ©fĂ©rable Ă MinIO
Deux dĂ©cisions se sont posĂ©es en recettant un pipeline dâimport qui Ă©crit sur S3, sans le moindre accĂšs AWS en local.
La premiÚre est une question technique : pourquoi alias: fonctionne alors que class: échoue dans un override when@dev ?
La seconde est une dĂ©cision dâarchitecture : pourquoi choisir un double maison jetable plutĂŽt que MinIO ?
Le S3 nâest finalement quâun prĂ©texte. Les deux rĂ©ponses se gĂ©nĂ©ralisent Ă bien dâautres situations.
Le contexte
Une plateforme Symfony 5.3 importe des fichiers dâachats dĂ©posĂ©s par une centrale. Les lignes qui rĂ©fĂ©rencent une entitĂ© encore inconnue sont mises de cĂŽtĂ© dans un fichier de rejets sur S3 ; un Ă©cran dâadministration permet ensuite de rĂ©soudre lâentitĂ©, puis de rĂ©importer ces lignes.
Le service qui parle Ă S3, AwsService, tire ses credentials du rĂŽle de tĂąche ECS (CredentialProvider::ecsCredentials()), donc dâune infrastructure AWS uniquement disponible en production. Le nom du bucket vient quant Ă lui dâune ligne de paramĂ©trage en base.
En local, avec DDEV : aucune variable AWS_*, aucun conteneur MinIO.
Tout appel Ă read(), putPro() ou move() est donc vouĂ© Ă lâĂ©chec. Pire : getBucket() renvoie null et le service Ă©choue silencieusement (return false, sans exception).
Une recette qui se contente de constater que « ça nâa pas plantĂ© » peut donc passer sans que rien ne se soit rĂ©ellement produit.
Il fallait exercer ce flux pour trois tickets successifs sur le mĂȘme pĂ©rimĂštre, Ă chaque fois sur une implĂ©mentation prĂȘte mais pas encore livrĂ©e.
Deux solutions, toutes les deux valables
Deux approches étaient possibles.
-
MinIO. Ajouter un conteneur S3-compatible au
.ddev/config.yamlde lâĂ©quipe, et faire accepter ĂAwsServiceun endpoint et des credentials statiques en environnement de dĂ©veloppement. -
Un double maison. Créer un fake de
AwsServicequi redirige les opérations vers le systÚme de fichiers local (var/fake-s3/), puis le brancher à la place du vrai service par un blocwhen@devnon commité. Une fois la recette terminée, le double disparaßt.
Les deux sont valables.
Le choix nâest donc pas une question de faisabilitĂ© mais une question de contexte.
when@dev : substituer une implémentation pour un seul environnement
when@<env> (introduit dans Symfony 5.3) permet de scoper de la configuration à un environnement dans un fichier de configuration normal, plutÎt que dans un fichier séparé sous config/services/dev/.
Le bloc nâest pris en compte que lorsque kernel.environment correspond Ă lâenvironnement <env> ciblĂ© et il est traitĂ© aprĂšs le corps principal du fichier. Il peut donc redĂ©finir des services existants.
En production, ce bloc nâest pas pris en compte : le conteneur de production ne contient aucune dĂ©finition issue de cet override.
Câest prĂ©cisĂ©ment ce qui en fait un bon rĂ©ceptacle pour un hack temporaire : tout lâoverride tient dans un bloc contigu et greppable, en fin dâun seul fichier.
On le voit facilement au git diff et le supprimer est trivial. De toute façon câest pour un test, normalement on ne livre rien Ă la fin.
Le plus petit changement réversible possible.
# âââ TEMPORAIRE â recette locale, NE PAS COMMITER âââ
when@dev:
services:
App\Service\AwsService:
...
class: contre alias: : redéfinir, ou simplement pointer ailleurs
Classiquement un override consiste à remplacer la classe utilisée par le service de test :
when@dev:
services:
App\Service\AwsService:
class: App\Service\FakeAwsServiceForManualTesting
Dans le cas rencontré, cette configuration aboutit à une erreur au premier appel :
Fail
Too few arguments to function
App\Service\FakeAwsServiceForManualTesting::__construct(),
0 passed ⊠and exactly 3 expected
Pourquoi ?
Il faut regarder comment le conteneur a été construit.
Le services.yaml par dĂ©faut contient notamment des _defaults (autowire: true, autoconfigure: true, bind:âŠ) puis une ressource :
services:
# default configuration for services in *this* file
_defaults:
autowire: true
autoconfigure: true
# makes classes in src/ available to be used as services
App\:
resource: '../src/'
# ...
Cette ressource enregistre notamment App\Service\AwsService et App\Service\FakeAwsServiceForManualTesting comme services, avec leurs dĂ©finitions issues de lâauto-dĂ©couverte.
Mais une configuration placĂ©e sous when@dev.services constitue une nouvelle couche de configuration. Il faut donc ĂȘtre prudent lorsquâon y redĂ©finit un service : on ne doit pas supposer que toute la configuration implicite de la dĂ©finition initiale sera conservĂ©e telle quelle.
Dans le cas rencontrĂ©, la redĂ©finition avec class: nâa pas conservĂ© la configuration nĂ©cessaire au constructeur du fake. Le service sâest retrouvĂ© avec une dĂ©finition qui ne savait plus rĂ©soudre ses trois dĂ©pendances.
On pourrait rendre cette redéfinition fonctionnelle en lui redonnant ce dont elle a besoin, par exemple avec autowire: true ou des arguments: explicites.
Mais ce nâest pas ce que lâon cherche ici, on ne veut pas redĂ©finir AwsService.
On veut dire :
« Quand quelquâun demande
AwsServiceen développement, donne-lui plutÎt ce service-là . »
Câest exactement le rĂŽle dâun alias.
when@dev:
services:
App\Service\AwsService:
alias: App\Service\FakeAwsServiceForManualTesting
public: true
Un alias nâest pas une nouvelle dĂ©finition. Câest un pointeur : lâidentifiant App\Service\AwsService se rĂ©sout vers le service enregistrĂ© sous App\Service\FakeAwsServiceForManualTesting.
La dĂ©finition du fake existe dĂ©jĂ grĂące Ă lâauto-dĂ©couverte de src/. Elle conserve donc sa propre configuration et son autowiring.
On ne rouvre aucune définition : on change simplement vers laquelle le conteneur pointe.
public: true est nĂ©cessaire ici parce quâune commande console jetable va Ă©galement rĂ©cupĂ©rer le service par son identifiant.
En bref
class:sert Ă configurer une dĂ©finition ;alias:sert Ă dire « Ă cet endroit, utilise cette autre implĂ©mentation ».Ce nâest pas une rĂšgle absolue pour toutes les substitutions Symfony, mais câest un excellent rĂ©flexe lorsquâon dispose dĂ©jĂ de deux services correctement dĂ©finis : si le besoin est simplement de faire pointer un identifiant vers une autre implĂ©mentation, lâalias est lâexpression la plus Ă©troite de lâintention.
On ne reconstruit pas ce qui existe déjà . On change le pointeur.
Choisir en fonction du contexte, pas des capacités
MinIO est plus fidĂšle Ă une infrastructure S3 rĂ©elle. Ăa nâen fait pas le bon choix ici.
Les axes qui ont tranché sont les suivants.
Rayon dâimpact
La solution MinIO modifie AwsService qui est déjà commité, partagé par toute la plateforme, présent dans tous les environnements, y compris les chemins S3 de production.
La solution avec le double ne touche aucun code commité : un fichier neuf et un bloc when@dev, tous deux jetés à la fin.
Le coĂ»t dâune erreur est donc bornĂ© par ce quâon met en jeu.
Amortissement, ou sur-ingénierie
MinIO est un investissement.
Il devient intĂ©ressant si lâĂ©quipe doit rĂ©guliĂšrement tester des flux S3 en local. Le besoin rĂ©el ici Ă©tait beaucoup plus petit : trois tickets sur deux semaines, puis probablement plus rien avant des mois.
Sans rĂ©currence pour lâamortir, construire le harnais MinIO revient Ă payer un coĂ»t dâinfrastructure, de configuration et de maintenance pour une capacitĂ© que personne nâa demandĂ©e.
Le besoin est temporaire, la solution peut donc lâĂȘtre aussi.
Demi-vie et réversibilité
Le hack a une demi-vie de quelques jours. Le retirer, câest supprimer le bloc et le fake, puis Ă©ventuellement vider le cache.
La modification MinIO est permanente par construction. Or une chose permanente rĂ©clame un propriĂ©taire, de la documentation, une ligne dâonboarding et de la maintenance.
Une branche endpoint dans AwsService, que plus personne nâexerce quelques mois plus tard, est exactement le genre de code qui peut finir par accueillir un bug sans que personne ne sâen aperçoive.
Alignement des modes de défaillance
Le double échoue comme le comportement attendu par le métier :
fichier absent â lecture vide â badge « non importĂ© ».
Pas de faux vert.
MinIO, lui, peut Ă©chouer pour des raisons qui nâont rien Ă voir avec le mĂ©tier : mauvais bucket, mauvais endpoint, path-style, rĂ©gion, credentialsâŠ
On se retrouve alors Ă tester le harnais de test.
Fidélité consommée
La recette valide ici du métier :
- le moteur de réimport parse-t-il correctement le fichier de rejets ?
- insĂšre-t-il les bonnes lignes ?
- bascule-t-il correctement lâĂ©tat ?
Ce mĂ©tier est indiffĂ©rent Ă lâorigine des octets.
Reproduire fidÚlement la sémantique S3 (cohérence, multipart, ACL, etc.) ajoute donc une fidélité que le test ne lit jamais.
La fidĂ©litĂ© quâon ne consomme pas est un coĂ»t, pas une qualitĂ©.
Coût de communication
Un relecteur qui voit FakeAwsServiceForManualTesting et un bloc NE PAS COMMITER a tout compris en trente secondes.
MinIO demande davantage : une note de conception, une explication en Ă©quipe, une mise Ă jour du guide dâonboarding, et potentiellement une rĂ©ponse Ă la question :
« Câest quoi ce paramĂštre
endpointsurAwsService? »
Et cela, pendant toute la durée de vie de la solution.
Ce que le double contient
Le double ne cherche pas Ă reproduire S3.
Il reproduit uniquement la surface publique réellement appelée par le code sous test.
Les helpers privĂ©s (put, list, check) ne peuvent pas ĂȘtre surchargĂ©s ; on surcharge donc les wrappers publics utilisĂ©s par lâapplication :
class FakeAwsServiceForManualTesting extends AwsService
{
private string $root;
public function __construct(
KernelInterface $kernel,
LoggerInterface $logger,
ParametersService $parametersService
) {
parent::__construct($kernel, $logger, $parametersService);
$this->root = $kernel->getProjectDir() . '/var/fake-s3';
}
public function read(
string $app,
string $filename,
string $folder
): ?string {
$path = "{$this->root}/" . trim($folder, '/') . "/$filename";
return is_file($path)
? (file_get_contents($path) ?: '')
: '';
}
public function putPro(
string $filename,
string $content,
string $folder
): bool {
$path = "{$this->root}/" . trim($folder, '/') . "/$filename";
@mkdir(dirname($path), 0777, true);
return file_put_contents($path, $content) !== false;
}
// putShop / putFTP / copy / delete* / list* :
// mĂȘme forme, sur var/fake-s3/.
// move() n'est pas surchargée :
// la classe mĂšre la fait en copy()+delete().
}
Ce nâest pas un Ă©mulateur S3 et câest volontaire. Le double implĂ©mente uniquement ce que le flux testĂ© consomme.
Tester le métier sans navigateur
Pour piloter les actions du contrĂŽleur sans passer par le navigateur, une commande console jetable injecte le mĂȘme service mĂ©tier que le contrĂŽleur.
Ce service est dĂ©sormais backĂ© par le fake grĂące Ă lâalias.
La commande appelle donc les mĂȘmes mĂ©thodes que le parcours applicatif.
Tout le cahier de test peut se dĂ©rouler en CLI ; le navigateur ne sert plus quâĂ confirmer lâUI.
Câest aussi ce qui rend dĂ©fendable lâargument :
On a validé le métier, pas S3.
Le principe
Deux rĂ©flexes de sĂ©nioritĂ© ressortent de cette expĂ©rience, lâun technique, lâautre architectural.
- Pour substituer une implĂ©mentation, on ne redĂ©finit pas sa configuration si lâon peut simplement pointer vers une autre dĂ©finition existante.
alias:exprime cette intention ;class:est Ă utiliser lorsquâon veut rĂ©ellement configurer ou redĂ©finir une dĂ©finition. - On choisit la solution dont le coĂ»t, humain autant que technique, est proportionnĂ© Ă la taille et Ă la durĂ©e de vie rĂ©elles du problĂšme : rayon dâimpact, maintenance, onboarding, rĂ©versibilitĂ©, frĂ©quence dâutilisation.
Un jetable assumé bat souvent un permanent mal entretenu.
Et quand le contexte change, on refait le calcul.
Ici, le besoin Ă©tait temporaire. Le double lâĂ©tait aussi.
Le signe que le choix Ă©tait bon est peut-ĂȘtre le plus concret : le pattern a Ă©tĂ© capturĂ© dans la base de connaissances de lâĂ©quipe, puis rĂ©utilisĂ© tel quel sur les deux tickets suivants.
Vingt minutes de mise en place, trois fois, contre un chantier de plusieurs jours.
Ce nâĂ©tait pas la solution la plus complĂšte. CâĂ©tait la solution proportionnĂ©e.